Skip to content

fix: compile on every react-native in the peer range, not just 0.81 - #429

Open
YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/react-native-0-86-types
Open

fix: compile on every react-native in the peer range, not just 0.81#429
YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/react-native-0-86-types

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

peerDependencies says react-native: ">=0.81". 0.81 is the only minor in that range the package compiles on. Measured with yarn typecheck at f70c402, one install per version:

react-native errors
0.81.4 0
0.82.0 6
0.83.0 7
0.84.0 7
0.85.3 7
0.86.0 7

Two unrelated causes, with different start points, plus a jest bootstrap failure that makes the suite unrunnable on 0.86. This PR makes all six compile and keeps 0.81 exactly where it is.

The seven on 0.86:

src/components/FlatList.tsx(12,16): error TS2590: Expression produces a union type that is too complex to represent.
src/jest/index.ts(24,29): error TS2345: Argument of type 'null' is not assignable to parameter of type 'ColorSchemeName'.
src/jest/index.ts(25,19): error TS2345: Argument of type 'null' is not assignable to parameter of type 'Value'.
src/native/api.tsx(76,31): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Value'.
src/native/reactivity.ts(220,3): error TS2345: Argument of type 'ColorSchemeName | null | undefined' is not assignable to parameter of type 'ColorSchemeName | Read<ColorSchemeName, ColorSchemeName>'.
src/native/reactivity.ts(222,57): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Value'.
src/web/api.tsx(72,3): error TS2322: Type '() => ColorSchemeName | null | undefined' is not assignable to type '() => ColorSchemeName'.

1. ColorSchemeName inverts at 0.82 (6 errors, 0.82 and later)

react-native's own type changes meaning inside the declared range:

ColorSchemeName Appearance.setColorScheme accepts null at runtime
0.81 "light" | "dark" | null | undefined ColorSchemeName | null | undefined mapped to "unspecified"
0.82+ "light" | "dark" | "unspecified" ColorSchemeNamenull rejected passed straight through

runtime.types.ts re-exported that type as ColorScheme's parameter and return, so the package's public API changed shape with the installed react-native, and the implementation could not satisfy both ends. The four errors reporting a bare generic Value are cascades of the failed observable<ColorSchemeName>(...) call at reactivity.ts:220.

The two setColorScheme parameter types overlap only on "light" | "dark", so no literal naming "follow the system" type-checks on both. The runtime does not split the same way: "unspecified" is what the native module receives on every version, because 0.81 maps null to it on the way in.

ColorSchemeName is now the package's own union of every spelling, "light" | "dark" | "unspecified" | null | undefined. Both directions widen, so nothing a caller passes today stops working, and "unspecified" becomes writable on 0.81 too. The one incompatible call goes through setAppearanceColorScheme: "light" and "dark" keep the shared declaration, everything else is written as "unspecified" through a locally restated signature — one place, reason inline, covering only the value the versions disagree about.

2. DotNotation walks into component instances (TS2590, 0.83 and later)

Different cause, so worth reading separately.

ScrollViewProps.scrollViewRef is a RefObject<ScrollView>, and ScrollView carries ScrollViewProps again — the prop graph DotNotation enumerates is cyclic. Bounded at MaxDepth 10, that one prop contributes 4,654 paths on 0.81. From 0.83 the host instance surface is wide enough that building the union crosses TypeScript's 100,000-member limit, so StyledConfiguration<typeof FlatList> fails and takes src/components/FlatList.tsx with it. FlatList.d.ts is unchanged across all of these versions; the growth is underneath it.

None of those paths can ever be a mapping target — they run through an object that does not exist until render. Treating a class component instance as a leaf removes the cycle at its source. Lowering MaxDepth would only move the threshold to whichever prop type grows next.

Path-set diff on 0.81, where the old and new definitions both resolve:

before after removed added
ViewProps 733 733 0 0
ScrollViewProps 7,482 2,094 5,388 0
FlatListProps<unknown> 9,204 3,816 5,388 0
Pick<ScrollViewProps, "scrollViewRef"> 4,656 2 4,654 0

Every removed path traverses a ref (.current.). Nothing is added.

3. The suite cannot bootstrap on 0.86

Independent of the types, and a prerequisite for testing any of this. jest-expo@56 declares @react-native/jest-preset as a non-optional peer and resolves setupFiles and testEnvironment through it. Nothing provided it, so the suite ran on the copy react-native shipped in-tree. 0.86 deleted that copy; its jest-preset.js is now a shim that throws, and .config/jest.config.cjs dies before a single test loads:

The React Native Jest preset that jest-expo relies on has moved to a separate package.
    at Object.<anonymous> (node_modules/jest-expo/jest-preset.js:16:13)
    at Object.<anonymous> (.config/jest.config.cjs:4:18)

The package has no 0.81.x release — 0.85.0 is the first — so it cannot be pinned to this repo's react-native. ^0.85.0 matches jest-expo's own declared range and works at both ends. On 0.81.4 it is a no-op: the suite is bit-identical with and without it.

Verification

Typecheck, every minor in the range, one install each, root and example/ manifests moved together:

0.81.4 0.82.0 0.83.0 0.84.0 0.85.3 0.86.0
upstream/main 0 6 7 7 7 7
this branch 0 0 0 0 0 0

Lint, build and the full suite at both ends of the range:

react-native@0.81.4 react-native@0.86.0
yarn lint clean clean
yarn build ok ok
yarn test 3 failed, 21 skipped, 1055 passed, 1079 total 3 failed, 21 skipped, 1055 passed, 1079 total

Identical, with numRuntimeErrorTestSuites: 0 on each — every suite loaded. The 3 failures are pre-existing on main before this branch: babel-plugin-tester path mismatches over an unrewritten relative require("../View"), reproducing on Windows only. Every figure above is the second of two consecutive runs, since a cold first run in a fresh tree can drop suites and silently subtract their whole count.

Running the suite on 0.86 also needs @react-native/babel-preset at 0.86 — babel-preset-expo@54 pins 0.81.4, whose codegen plugin cannot parse 0.86's native component specs (Unable to determine event arguments for "onChange", 21 suites down). That is an Expo SDK pairing issue rather than anything in this package, so it is not in the diff; I pinned it through resolutions locally for the measurement only.

Tests

Both fixes are type-surface, so each carries a compile-plane assertion, and the color scheme one carries a runtime assertion as well.

src/__tests__/native/color-scheme.test.tsx

  • Compile plane: three aliases assert ColorSchemeName covers react-native's own union, Appearance.getColorScheme()'s return, and the change-listener payload. They are read off the installed react-native rather than restating either spelling, so yarn typecheck fails at whichever version the checkout is on the moment coverage lapses.
  • Runtime plane: NativeAppearance is mocked so writes are observable — it is TurboModuleRegistry.get('Appearance'), null under jest on every version, which is why setColorScheme was previously unobservable in tests. Five cases assert what the native module actually receives; a sixth asserts @media (prefers-color-scheme: dark) engages on "dark" and releases on "unspecified".

src/__tests__/native/components.test.tsx

  • Compile plane: scrollViewRef and scrollViewRef.current are reachable targets, and a @ts-expect-error pins that scrollViewRef.current.props.style is not. That directive is what makes the guard work on 0.81 and 0.82 too, where TS2590 never fires — reverting the fix there fails with TS2578: Unused '@ts-expect-error' directive.
  • No runtime plane: dot-notation.types.ts compiles to export {} (72 bytes), so it has no runtime surface. FlatList's mapping behaviour is already covered by className-with-style.test.tsx, which passes at both ends.

Each fix was mutation-proved by reverting it and watching the guard go red:

Reverted 0.81.4 0.86.0
component-instance leaf TS2578 unused @ts-expect-error TS2578 + the original FlatList.tsx(12,16) TS2590
package-owned ColorSchemeName TS2345 on colorScheme.set("unspecified") that plus the original web/api.tsx TS2322
setAppearanceColorScheme sends the raw value passes — 0.81 maps null itself, so this version cannot see it 2 tests red: native module receives null / undefined
@react-native/jest-preset devDependency no effect — in-tree preset still resolves jest config fails to load, whole suite unrunnable

Out of scope, noticed while here

react-native-web@0.21's Appearance exports getColorScheme and addChangeListener but no setColorScheme, so colorScheme.set() on web throws TypeError at runtime today. Unrelated to this, and unchanged by this PR.

jest-expo 56 declares @react-native/jest-preset as a non-optional peer and
resolves setupFiles and testEnvironment through it. Nothing provided it, so the
suite quietly ran on the copy react-native ships in-tree.

react-native 0.86 deleted that copy. Its jest-preset.js is now a shim that
throws unless @react-native/jest-preset is installed, which takes the suite down
in jest.config.cjs before a single test loads. Declaring the peer keeps the
config loadable across the whole peer range.

Verified on react-native 0.81.4 that the preset changes nothing: 1048 passed,
3 failed, 21 skipped, 1072 total both with and without it, where the 3 failures
are pre-existing Windows path mismatches in the babel-plugin-tester suites.
react-native's ColorSchemeName is not stable across this package's
`react-native: >=0.81` peer range. Up to 0.85 it is
"light" | "dark" | null | undefined; from 0.86 it is
"light" | "dark" | "unspecified", and Appearance.setColorScheme rejects null.
Six of the seven type errors the package produces on 0.86 come from that one
inversion, and while the type is re-exported from react-native no single source
can satisfy both ends of the range.

ColorSchemeName is now the package's own union of every spelling, so colorScheme
is one API across the range whichever version is installed. The one call the two
versions declare incompatibly, the "follow the system" write, goes through
setAppearanceColorScheme: "light" and "dark" keep the shared signature, and
everything else is sent as "unspecified". That reaches the native module
identically on both floors, because 0.85 and earlier map null to "unspecified"
themselves before handing it down.

The new suite pins both planes. The type aliases are read off the installed
react-native rather than restating either spelling, so typecheck fails at
whichever end of the range the checkout is on if coverage ever lapses; the
runtime cases assert what the native module actually receives.
ScrollViewProps.scrollViewRef is a RefObject<ScrollView>, and ScrollView holds
ScrollViewProps again, so the prop graph DotNotation walks is cyclic. Bounded at
MaxDepth 10 that single prop contributes 4,654 paths on react-native 0.81, and
on 0.86, where the host instance surface is wider, building the union crosses
TypeScript's 100,000 member limit: StyledConfiguration<typeof FlatList> fails
with TS2590 and takes src/components/FlatList.tsx with it.

None of those paths can ever be a mapping target. They run through an object
that does not exist until render. Treating a class component instance as a leaf
cuts the cycle at its source, rather than lowering MaxDepth, which would only
move the threshold to the next prop type that grows.

Measured on react-native 0.81.4, where both the old and new definitions resolve:
ViewProps is unchanged at 733 paths, ScrollViewProps drops from 7,482 to 2,094
and FlatListProps from 9,204 to 3,816. Every removed path traverses a ref, and
nothing is added.
Measured `yarn typecheck` on upstream/main against every minor in the peer
range, one install each: 0.81.4 gives 0 errors, 0.82.0 gives 6, and 0.83.0
through 0.86.0 give 7. So the color scheme break starts at 0.82, where
ColorSchemeName became "light" | "dark" | "unspecified" and setColorScheme
stopped accepting null, and the TS2590 break starts at 0.83, where the wider
host instance surface pushes the cyclic ref enumeration over TypeScript's union
limit. 0.81 is the only minor in a `>=0.81` range that compiles.

The runtime half moves with it: 0.82 and later pass the argument straight to the
native module, so only 0.81 maps null to "unspecified" on the way in. That makes
"unspecified" the value that is right everywhere, which is what the code already
sends; only the comments named the wrong boundary.
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix: compile and test on the whole react-native peer range (0.81 to 0.86) fix: compile on every react-native in the peer range, not just 0.81 Aug 15, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Cross-referencing #415 and #429 — they merge with zero conflicts and the result does not compile. Flagging it now, because nothing will warn whoever lands the second one.

git merge-tree --write-tree reports no conflict at all. Running tsc --noEmit on that merged tree gives exactly one error, repo-wide:

src/native/api.tsx(83,31): error TS2345: Argument of type 'ColorSchemeName' is not
  assignable to parameter of type '…Appearance").ColorSchemeName'.
  Type '"unspecified"' is not assignable to type 'ColorSchemeName'.

The two halves are each correct alone:

On the merge, #429's wider union reaches a call react-native 0.81 types as 'light' | 'dark' | null | undefined, and "unspecified" is not a member.

Neither PR can pre-empt this, which is why it is a note rather than a fix on one of them. #415 cannot import src/color-scheme.ts — that file does not exist on main or on its branch. #429 has no Appearance.setColorScheme call in src/native/api.tsx to route, because on its branch that file has no setter.

The resolution is one line at merge time: route the native setter through the same seam the web one already uses, rather than calling Appearance directly.

// src/native/api.tsx
import { setAppearanceColorScheme } from "../color-scheme";

setAppearanceColorScheme(value);   // instead of Appearance.setColorScheme(value)

Measured: that takes the merged tree from one error to zero. The seam exists precisely because the one member the supported react-native range declares incompatibly is the "follow the system" write, so this is what it was built for.

Worth pairing with a compile-level guard, since the failure is a type error rather than a behaviour: the merged tree must typecheck. A runtime twin already exists on #429 at src/__tests__/native/color-scheme.test.tsx:74-76, which drives colorScheme.set("unspecified").

YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-css that referenced this pull request Aug 16, 2026
…tarts at

These comments say 0.86 in four places. Measured across every react-native in
the Yarn cache, the break starts at 0.82.0 — four minor versions earlier:

  0.81.2 / 0.81.4 / 0.81.5   ColorSchemeName = 'light' | 'dark' | null | undefined
  0.82.0 … 0.86.0            ColorSchemeName = 'light' | 'dark' | 'unspecified'

The runtime moves in the same release. 0.81.5 coerces the nullish request —
`NativeAppearance.setColorScheme(colorScheme ?? 'unspecified')` — and 0.82.0
passes the argument through verbatim.

nativewind#429 already names 0.82 on its own surface, so left alone the two branches
would land in one tree disagreeing about one boundary.

The cache-write sentence gets rewritten rather than renumbered, because the
range has three states and not two: before 0.82 the cache is a read-back of the
native module, from 0.82 it is the requested value, and later in the range
`"unspecified"` is resolved against the OS before being stored. Scoping the
sentence to a RESOLVED scheme collapses the last two — the only value they
disagree about is `"unspecified"`, which is exactly the value the guard below
declines to announce — and it keeps the comment from naming a boundary this
measurement cannot place: the cache holds 0.84.1 and 0.85.3 but not 0.85.0
through 0.85.2, so the second transition is bounded only to (0.84.1, 0.85.3].

Comments only; no behaviour changes. `reactivity.ts:229` is left alone — "the
request 0.81 spells `null`" is a statement about 0.81 and is correct.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Following up on the cross-reference above with two measured additions, one of which is a trap worth naming explicitly.

An inline cast at the call site compiles, and is the wrong resolution

The obvious shortcut for the merge break is to widen the argument in place rather than route through the seam:

Appearance.setColorScheme(value as Parameters<typeof Appearance.setColorScheme>[0]);

That does take the merged tree to zero type errors, on both react-native ends of the peer range. It is still wrong, because it silences both arms of a union that disagrees for a reason:

0.81.5  Appearance.js   NativeAppearance.setColorScheme(colorScheme ?? 'unspecified');
0.82.0  Appearance.js   NativeAppearance.setColorScheme(colorScheme);

Up to 0.81.5 a nullish request is coerced to "unspecified" before it reaches the native module. From 0.82.0 the coercion is gone and the argument is stored and forwarded verbatim. colorScheme is exported from react-native-css/runtime, and #429 deliberately widens the public ColorScheme.set to accept whichever spelling the installed version emits — so with a cast in place, colorScheme.set(null) puts null into Appearance's cache and hands null to the native module on ≥0.82.

That is the failure #415's own comment forbids in the same function: "Announcing it would put a value in Appearance's cache that no reader can render." setAppearanceColorScheme exists precisely to prevent it, so the seam is the resolution and the cast routes around it.

The boundary is 0.82.0, not 0.86

Measured across every react-native in the Yarn cache, by extracting Libraries/Utilities/Appearance.d.ts from each:

0.81.2 / 0.81.4 / 0.81.5   type ColorSchemeName = 'light' | 'dark' | null | undefined;
0.82.0 … 0.86.0            type ColorSchemeName = 'light' | 'dark' | 'unspecified';

The type narrowing and the loss of the ?? 'unspecified' coercion land in the same release. #429 already names 0.82 on its own surface; #415 said 0.86 in four comments and now names 0.82 too, so the two branches no longer disagree about one boundary when they meet.

One nuance worth recording for whoever touches this next, because it is easy to collapse into the same number: there is a third state later in the range, where setColorScheme resolves "unspecified" against the OS before storing it, rather than storing it verbatim. It is present in 0.85.3 and absent in 0.84.1. I have not placed it more tightly than (0.84.1, 0.85.3], so nothing above names it as a version.

A latent one, for #415

#415's three new colour-scheme test files do not type-check against ≥0.82 — colorScheme.set(null) and friends, where null is no longer in ColorSchemeName. That includes color-scheme-appearance-rn-0-86.test.tsx, which models 0.86 while being written against 0.81's type surface. It is latent for both PRs because neither bumps devDependencies.react-native past 0.81.4, so CI never exercises the surface either PR exists to support — which is itself worth a look on #429.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant